import * as path from 'node:path';
import * as url from 'node:url';
import * as fs from 'node:fs/promises';

import { minify as htmlMinifyImpl } from 'html-minifier-terser';

import { MODULE_PRESETS } from './html/preset.mjs';
import { maybeInline, needElement } from './html/inline.mjs';
import { defineConfig } from 'vite';
import { JSDOM } from 'jsdom';

import {
    HTML_DIR,
    VENDOR_DIR,
    PRESET_DIR,
    BUILD_DIR,
} from './gulpfile.mjs';

// vite build pipeline is simplified compared to the gulp one
// assets are mostly handled through the rollup <-> vite
//
// two parts are not
// - index.html pre-generation has to happen before build starts
// - modules stripping before and after index.html is pre-gened
//
// compared to (some) of the external plugins
// - https://www.npmjs.com/package/vite-plugin-singlefile
//   https://www.npmjs.com/package/vite-plugin-inline-source
//
//   html assets are sourced from the OutputBundle after the build
//   no extra config modifications; html inline handled differently
//
// - https://www.npmjs.com/package/vite-plugin-html
//   https://www.npmjs.com/package/vite-plugin-html-inject
//
//   inline-source custom element used instead of templating
//   package JSDOM dependency instead of plugin parser(s)

/** @import { PluginOption } from 'vite' */

/**
 * @param {import("./html/preset.mjs").Modules} modules
 * @returns {PluginOption}
 */
function stripModules(modules) {
    return {
        name: stripModules.name,
        enforce: 'pre',
        async transformIndexHtml(html) {
            const dom = new JSDOM(html);
            const doc = dom.window.document;

            for (const elem of Array.from(doc.querySelectorAll('[class*=module-]'))) {
                if (!needElement(elem, modules)) {
                    elem.parentElement?.removeChild(elem);
                }
            }

            return dom.serialize();
        }
    };
}

/**
 * @returns {PluginOption}
 */
function inlineHtmlPre() {
    const sources = [];

    return {
        name: inlineHtmlPre.name,
        enforce: 'pre',
        configureServer(server) {
            server.watcher.on('change', (file) => {
                if (file.endsWith('.html')) {
                    server.ws.send({
                        type: 'full-reload',
                        path: '*',
                    });
                }
            });
        },
        async transformIndexHtml(html) {
            const dom = new JSDOM(html);
            const doc = dom.window.document;

            for (const elem of Array.from(doc.querySelectorAll('inline-source'))) {
                const src = elem.getAttribute('src');
                if (!src) {
                    continue;
                }

                const data = await fs.readFile(path.join(HTML_DIR, src));
                elem.outerHTML = data.toString();

                sources.push(src);
            }

            return dom.serialize();
        }
    };
}

/**
 * @returns {PluginOption}
 */
function inlineAssetsPost() {
    return {
        name: inlineAssetsPost.name,
        apply: 'build',
        enforce: 'post',
        async transformIndexHtml(html, ctx) {
            // possibly, everything was already handled by some other plugin
            if (!ctx.bundle) {
                return html;
            }

            /**
             * vite is expected to clean-up src=..., so no need to resolve(src)
             * bundle at this point should contain every remaining asset on the page
             * @param {string} src
             */
            function load(src) {
                const output = (ctx.bundle ?? {})[src];
                if (!output) {
                    return '';
                }

                let code = '';
                if (output.type === 'asset' && typeof output.source === 'string') {
                    code = output.source;
                }
                if (output.type === 'chunk') {
                    code = output.code;
                }

                return code;
            }

            /**
             * output bundle should be kept empty, only index.html is written
             * @param {string} src
             */
            function post(src) {
                delete (ctx.bundle ?? {})[src];
            }

            const dom = new JSDOM(html);
            const doc = dom.window.document;

            for (const elem of Array.from(doc.querySelectorAll('link,script'))) {
                await maybeInline(dom, elem, {load, post});
            }

            return dom.serialize();
        }
    };
}

/**
 * @returns {PluginOption}
 */
function htmlMinify() {
    return {
        name: htmlMinify.name,
        apply: 'build',
        enforce: 'post',
        async transformIndexHtml(html) {
            html = await htmlMinifyImpl(html, {
                collapseWhitespace: true,
                removeComments: true,
                minifyCSS: false,
                minifyJS: false,
            });

            return html;
        },
    };
}

/**
 * @param {string} name
 * @returns {PluginOption}
 */
function rename(name) {
    return {
        name: rename.name,
        apply: 'build',
        enforce: 'post',
        generateBundle(_, bundle) {
            bundle['index.html'].fileName = `index.${name}.html`;
        },
    };
}

export default defineConfig(async ({ mode }) => {
    if (!MODULE_PRESETS.has(mode)) {
        throw new Error(`'mode' has to be one of: ${Array.from(MODULE_PRESETS).join(', ')}`);
    }

    const preset = path.join(PRESET_DIR, mode);
    const constants = url.pathToFileURL(
        path.join(preset, 'constants.mjs'));

    const { MODULES: modules } = await import(constants.href);

    return {
        // build order is declared by the plugin, but make sure 'pre' happen exactly like this
        // stripModules has to also help out the inlineHtmlPre, since it is not aware of the module deps
        plugins: [
            stripModules(modules),
            inlineHtmlPre(),
            stripModules(modules),
            inlineAssetsPost(),
            htmlMinify(),
            rename(mode),
        ],
        // vite-specific overrides, both for the build and importmap helper script
        resolve: {
            alias: [
                {find: '/vendor', replacement: VENDOR_DIR},
                {find: '@build-preset', replacement: preset},
            ],
        },
        // one should be very careful when injecting relative paths in the html / js / configs
        // step into the HTML_DIR by default (aka `vite ... html/` in the cli)
        root: HTML_DIR,
        envDir: preset,
        // rollup settings stay mostly the same, with the exception of the @jaames/iro workaround
        build: {
            modulePreload: {
                polyfill: false,
            },
            emptyOutDir: true,
            outDir: BUILD_DIR,
            rollupOptions: {
                treeshake: {
                    moduleSideEffects: () => false,
                },
            },
        },
    };
});
